SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
11.1 KB · 206 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Download } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { JsonView } from '@/components/ui/json-view';10import { BarChart } from '@/components/charts/bar-chart';11import { getMetric, snapshotsForMetric, rankingRows, type Snapshot } from '@/lib/queries/rankings';12import { sourceInfoById } from '@/lib/queries/provenance';13import { fmtDate, fmtDateTime, fmtInt, fmtValue, scopeLabel, unitLabel, humanize } from '@/lib/format';14import { str, type SP } from '@/lib/search-params';1516export const dynamic = 'force-dynamic';1718export async function generateMetadata({ params }: { params: Promise<{ metric: string }> }): Promise<Metadata> {19  const m = await getMetric((await params).metric);20  return m ? { title: `${m.name} — ranking`, description: m.description } : { title: 'Ranking' };21}2223function Delta({ rank, prev }: { rank: number; prev: number | null }) {24  if (prev == null) return <span className="text-ink-4">new</span>;25  const d = prev - rank;26  if (d === 0) return <span className="text-ink-3">=</span>;27  return (28    <span className={d > 0 ? 'text-ok' : 'text-danger'} title={`Previous rank ${prev}`}>29      {d > 0 ? '▲' : '▼'}30      {Math.abs(d)}31    </span>32  );33}3435export default async function RankingPage({ params, searchParams }: { params: Promise<{ metric: string }>; searchParams: Promise<SP> }) {36  const { metric: slug } = await params;37  const sp = await searchParams;38  const metric = await getMetric(slug);39  if (!metric) notFound();40  const snapshots = await snapshotsForMetric(slug);41  const wanted = str(sp, 'scope');42  const snap: Snapshot | null = snapshots.find((s) => s.scope_key === wanted) ?? snapshots[0] ?? null;43  const rows = snap ? await rankingRows(snap.id) : [];44  const srcInfo = snap ? await sourceInfoById(snap.source_ids) : new Map();45  const eligibility = Object.entries(metric.eligibility ?? {});46  const isBurden = metric.category === 'burden' || metric.category === 'lethality' || metric.category === 'unmet_need';4748  return (49    <div>50      <PageHeader kicker={`Ranking · ${humanize(metric.category)}`} title={metric.name} lede={metric.description}>51        <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">52          <ClaimBadge kind="computed" />53          <span className="ci-mono">{metric.formula_version}</span>54          <span>unit: {unitLabel(metric.unit)}</span>55          <span>{metric.higher_is_worse == null ? 'higher is first (neutral)' : metric.higher_is_worse ? 'higher = worse' : 'higher = better'}</span>56          <Link href="/rankings" className="ci-link">57            All metrics58          </Link>59        </p>60      </PageHeader>6162      {snapshots.length === 0 ? (63        <EmptyState title="Ranking not yet computed" knows={[{ label: 'Metric definition and formula', href: `/methodology#${metric.slug}` }, { label: 'Source registry', href: '/sources' }, { label: 'Other rankings', href: '/rankings' }]}>64          {isBurden65            ? 'This metric depends on epidemiology observations. Global burden data (IARC / GLOBOCAN) is awaiting license review and SEER awaits credentials; no snapshot is produced until licensed observations exist for at least one scope with ten or more top-level cancers.'66            : 'This metric depends on counters that are refreshed after the relevant connector runs (ClinicalTrials.gov, PubMed, CIViC, GDC). No snapshot exists yet on this environment.'}67          <div className="mt-2">68            <span className="ci-kicker mr-2">Formula</span>69            <code className="ci-mono text-[12px]">{metric.formula}</code>70          </div>71        </EmptyState>72      ) : (73        <div className="grid gap-8 lg:grid-cols-[1fr_320px]">74          <div>75            <nav aria-label="Scope" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]">76              {snapshots.map((s) => (77                <Link key={s.id} href={`/rankings/${slug}?scope=${encodeURIComponent(s.scope_key)}`} aria-current={snap?.id === s.id ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${snap?.id === s.id ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>78                  {scopeLabel(s.scope_key)}79                </Link>80              ))}81            </nav>82            {snap && rows.length ? (83              <>84                <BarChart ariaLabel={`Top ${Math.min(15, rows.length)} by ${metric.name}`} unit={metric.unit} data={rows.slice(0, 15).map((r) => ({ label: r.canonical_name, value: r.value, muted: r.confidence === 'LOW' || r.confidence === 'INSUFFICIENT_DATA' }))} />85                <div className="ci-table-wrap mt-4">86                  <table className="ci-table">87                    <thead>88                      <tr>89                        <th className="num">Rank</th>90                        <th className="num">Δ</th>91                        <th>Cancer</th>92                        <th className="num">93                          {metric.name} ({unitLabel(metric.unit)})94                        </th>95                        <th className="num">Percentile</th>96                        <th>Confidence</th>97                        <th>Sources</th>98                        <th>Why #n?</th>99                      </tr>100                    </thead>101                    <tbody>102                      {rows.map((r) => (103                        <tr key={r.id}>104                          <td className="num font-medium">{r.rank}</td>105                          <td className="num">106                            <Delta rank={r.rank} prev={r.previous_rank} />107                          </td>108                          <td className="min-w-[220px]">109                            <Link className="ci-link" href={`/cancer/${r.slug}/rankings`}>110                              {r.canonical_name}111                            </Link>112                          </td>113                          <td className="num">{fmtValue(r.value, r.unit)}</td>114                          <td className="num text-ink-3">{r.percentile}</td>115                          <td>116                            <ConfidenceBadge level={r.confidence} />117                          </td>118                          <td className="text-[11.5px]">119                            {snap.source_ids.map((id) => {120                              const s = srcInfo.get(id);121                              return (122                                <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-mono mr-1 border border-rule px-1 text-ink-2 no-underline hover:border-accent">123                                  {s?.slug ?? id}124                                </Link>125                              );126                            })}127                          </td>128                          <td>129                            <details>130                              <summary className="ci-link text-[12.5px]">inputs</summary>131                              <div className="mt-1 max-w-[380px]">132                                <JsonView data={r.inputs} />133                                <p className="ci-mono mt-1 text-[10.5px] text-ink-4">ranking row {r.id}</p>134                              </div>135                            </details>136                          </td>137                        </tr>138                      ))}139                    </tbody>140                  </table>141                </div>142                <Freshness dataUpdatedAt={snap.generated_at} extra={`${fmtInt(snap.eligible_entities)} eligible entities · inputs hash ${snap.inputs_hash}`} />143              </>144            ) : (145              <EmptyState compact>This snapshot has no rows.</EmptyState>146            )}147          </div>148149          <aside className="space-y-6">150            {snap ? (151              <Section id="methodology" kicker="Methodology" title="How this table was built" level={3}>152                <KV153                  items={[154                    { k: 'Formula', v: <code className="ci-mono text-[12px]">{metric.formula}</code> },155                    { k: 'Version', v: <span className="ci-mono">{snap.formula_version}</span> },156                    { k: 'Scope', v: scopeLabel(snap.scope_key) },157                    { k: 'Scope key', v: <span className="ci-mono break-all text-[11.5px]">{snap.scope_key}</span> },158                    { k: 'Eligible entities', v: <span className="ci-num">{fmtInt(snap.eligible_entities)}</span> },159                    { k: 'Generated', v: fmtDateTime(snap.generated_at) },160                    { k: 'Inputs hash', v: <span className="ci-mono break-all text-[11.5px]">{snap.inputs_hash}</span> },161                    {162                      k: 'Sources',163                      v: snap.source_ids.length164                        ? snap.source_ids.map((id) => {165                            const s = srcInfo.get(id);166                            return (167                              <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-link mr-2">168                                {s?.name ?? id}169                              </Link>170                            );171                          })172                        : '—',173                    },174                    { k: 'Aggregation', v: metric.aggregation ?? 'none' },175                  ]}176                />177                {eligibility.length ? (178                  <>179                    <p className="ci-kicker mt-3">Eligibility</p>180                    <JsonView data={metric.eligibility} />181                  </>182                ) : null}183                <p className="mt-3 text-[12px] text-ink-3">184                  <Link href={`/methodology#${metric.slug}`} className="ci-link">185                    Full methodology186                  </Link>{' '}187                  · ties share a rank (1, 2, 2, 4) · previous rank refers to the last snapshot of the same scope.188                </p>189                <a href={`/api/export/rankings.csv?metric=${slug}&scope=${encodeURIComponent(snap.scope_key)}`} className="mt-3 inline-flex items-center gap-1.5 border border-rule px-2.5 py-1 text-[13px] text-ink no-underline hover:border-accent hover:text-accent">190                  <Download className="h-3.5 w-3.5" aria-hidden /> Download CSV (with attribution)191                </a>192              </Section>193            ) : null}194            {isBurden ? <Note tone="warn">Burden and lethality metrics depend on registry definitions (site groups, standard population, estimate type). Compare only within one table.</Note> : <Note>Counts of trials, publications and evidence measure registered activity, not disease burden or treatment quality.</Note>}195            {metric.slug.includes('mortality') || metric.slug.includes('survival') ? (196              <p className="text-[12px] text-ink-3">197                <Badge tone="outline">Deadliness</Badge> annual deaths ≠ age-standardized mortality ≠ mortality-to-incidence ratio ≠ 5-year survival. Each answers a different question.198              </p>199            ) : null}200          </aside>201        </div>202      )}203    </div>204  );205}206